Skip to content

perf(cli): defer startup prefetch tasks#6303

Merged
wenshao merged 17 commits into
QwenLM:mainfrom
water-in-stone:perf/speed-start
Jul 7, 2026
Merged

perf(cli): defer startup prefetch tasks#6303
wenshao merged 17 commits into
QwenLM:mainfrom
water-in-stone:perf/speed-start

Conversation

@water-in-stone

Copy link
Copy Markdown
Contributor

… (#3222)

What this PR does

This PR moves interactive telemetry SDK startup off the pre-render REPL critical path and starts it after the first Ink render alongside the
existing post-render startup prefetch work. It also keeps IDE startup awaited for prompt-interactive sessions that auto-submit an initial
prompt, while avoiding a duplicate post-render IDE connection in that path.

Why it's needed

Interactive startup should render the REPL before optional background work such as telemetry initialization, update checks, IDE connection for
ordinary TUI sessions, and housekeeping completes. At the same time, prompt-interactive and non-interactive flows must keep their first-
request correctness guarantees, especially IDE context and MCP tool availability.

Reviewer Test Plan

How to verify

Confirm that ordinary interactive startup defers telemetry initialization and IDE connection until after first render, while prompt-
interactive still awaits IDE context before the auto-submitted first request and does not connect IDE a second time after render. Confirm that
non-interactive behavior is unchanged and still waits for MCP readiness before the first model request.

Local verification commands run:

cd packages/core && npm run typecheck
cd packages/cli && npm run typecheck
cd packages/cli && npx vitest run src/startup/startup-prefetch.test.ts src/gemini.test.tsx src/core/initializer.test.ts
cd packages/core && npx vitest run src/config/config.test.ts -t "telemetry"  

All commands passed locally.

Evidence (Before & After)

Before: Telemetry and checkForUpdates were loaded before rendering in interactive CLI startup.

After: Telemetry and checkForUpdates are no longer loaded before rendering in interactive CLI startup.

Tested on

OS Status
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

Environment (optional)

Local npm workspace on macOS; verification used package-level TypeScript checks and targeted Vitest suites.

Risk & Scope

  • Main risk or tradeoff: Interactive telemetry events emitted before the post-render telemetry startup can be no-ops because this change
    intentionally does not add buffering.

  • Not validated / out of scope: Manual TUI timing capture, Windows-specific behavior, Linux-specific behavior, and full integration suites
    were not run locally.

  • Breaking changes / migration notes: None expected. Non-interactive, stream-json, ACP/Zed, and MCP first-request readiness semantics are
    preserved.

Linked Issues

Related to #3011 and #3222.

中文说明

What this PR does

本 PR 将交互式遥测 SDK 的启动从预渲染 REPL 关键路径中移出,改为在首次 Ink 渲染后与现有的渲染后启动预取工作一同启动。同时,对于自动提交初始提示词的 prompt-interactive 会话,保持 IDE 启动的 await 行为,并避免在该路径中重复进行渲染后的 IDE 连接。

Why it's needed

交互式启动应在渲染 REPL 之后再执行可选的后台工作,如遥测初始化、更新检查、普通 TUI 会话的 IDE 连接以及内务处理。与此同时,prompt-interactive 和非交互式流程必须保持其首次请求的正确性保证,尤其是 IDE 上下文和 MCP 工具可用性。

Reviewer Test Plan

How to verify

确认普通交互式启动将遥测初始化和 IDE 连接延迟到首次渲染之后,而 prompt-interactive 在自动提交的首次请求前仍会 await IDE 上下文,且不会在渲染后再次连接 IDE。确认非交互式行为保持不变,仍在首次模型请求前等待 MCP 就绪。

本地验证命令运行通过:

cd packages/core && npm run typecheck
cd packages/cli && npm run typecheck
cd packages/cli && npx vitest run src/startup/startup-prefetch.test.ts src/gemini.test.tsx src/core/initializer.test.ts
cd packages/core && npx vitest run src/config/config.test.ts -t "telemetry"

所有命令在本地均通过。

Evidence (Before & After)

修改前:对于普通的交互式的命令行启动方式,会在 Render 前加载 telemetry, 并执行 checkForUpdates
修改前:对于普通的交互式的命令行启动方式,不会在 Render 前加载 telemetry 和 checkForUpdates

Tested on

OS Status
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

Environment (optional)

macOS 上的本地 npm workspace;验证使用了包级别的 TypeScript 检查和针对性的 Vitest 套件。

Risk & Scope

-主要风险或权衡:在渲染后遥测启动之前发出的交互式遥测事件可能为 no-op,因为本次变更有意不添加缓冲机制。

  • 未验证 / 超出范围:手动 TUI 时序捕获、Windows 特定行为、Linux 特定行为以及完整集成套件未在本地运行。

  • 破坏性变更 / 迁移说明:无预期变更。非交互式、stream-json、ACP/Zed 以及 MCP 首次请求就绪语义保持不变。

Linked Issues

关联 #3011#3222

heyang.why added 6 commits July 4, 2026 17:13
…up prefetch

Address three test coverage gaps identified during code review:

- Assert mockStartEarlyStartupPrefetches in both kitty protocol tests
  (C1: API preconnect call was wired but never verified)
- Add Zed/ACP integration test verifying deferIdeConnection is false
  when getExperimentalZedIntegration returns true (C2: Zed path was
  entirely untested)
- Assert mockStartBackgroundHousekeeping in startup-prefetch test
  (C3: unconditional housekeeping dispatch was never verified)
@water-in-stone water-in-stone changed the title Perf/speed start perf(cli): defer startup prefetch tasks Jul 4, 2026
@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the updated PR — re-running triage on the latest commits.

Template looks good ✓ — all required headings, bilingual description, and reviewer test plan present.

Problem: Real, observed optimization target. Interactive startup blocks on telemetry SDK init, update checks, and IDE connection before first paint. Related to parent issue #3011 on startup performance. Evidence in the PR body shows what gets loaded before render. Not theoretical hardening.

Direction: Aligned. Startup performance is directly user-facing for a CLI tool. Deferring non-critical work past first render is well-established. Part of the same initiative as #3219 (startup profiler) and #3221 (lazy tool registration).

Size: 17 files changed, ~292 production logic lines (additions + deletions) after excluding tests (1006 lines), docs (369 lines), and generated files (0 lines). Touches packages/core/src/config/config.ts (16+1) — a core module — but the change is 3 lines (one boolean flag, one conditional guard, one getter). Below the 500-line escalation threshold. Cross-package surface is minimal.

Approach: Scope feels right. The new startup-prefetch.ts (160 lines) consolidates scattered fire-and-forget operations with runDeferredTask, WeakSet idempotency, proper error swallowing, and a 10s timeout on deferred IDE connection. The connectIdeForStartup extraction from initializer.ts is the minimum refactor needed. The IDE connection status UI (Footer + AppContainer + events) is a thoughtful addition addressing the race window between TUI render and IDE IPC completion.

All review findings from the prior round have been addressed:

  • --input-file first-request guarantee: hasRemoteInput now included in deferIdeConnection computation ✓
  • postRenderConnectIde default changed to false (opt-in) ✓
  • fallbackModel: undefined added to all test mock CliArgs
  • IDE connection timeout added via withTimeout
  • Error stack traces preserved in runDeferredTask
  • IDE connection status surfaced in Footer ✓

Moving on to code review. 🔍

中文说明

感谢更新的 PR——在最新提交上重新运行分诊。

模板完整 ✓ — 所有必要标题、双语说明和审查者测试计划齐全。

问题:真实的优化目标。交互式启动在首次渲染前阻塞了遥测 SDK 初始化、更新检查和 IDE 连接。关联父 issue #3011。PR 描述中有证据显示渲染前加载了什么。不是理论性加固。

方向:对齐。启动性能对 CLI 工具来说是直接影响用户体验的。将非关键工作延迟到首次渲染后是成熟的模式。属于 #3011 启动优化计划的一部分。

规模:17 个文件变更,排除测试(1006 行)、文档(369 行)和生成文件(0 行)后约 292 行生产逻辑。触及核心模块 packages/core/src/config/config.ts(16+1),但变更仅 3 行。低于 500 行阈值。

方案:范围合理。新的 startup-prefetch.ts(160 行)通过 runDeferredTaskWeakSet 幂等性、正确的错误吞没和 10 秒 IDE 连接超时整合了分散的 fire-and-forget 操作。IDE 连接状态 UI 是一个体贴的新增,解决了 TUI 渲染与 IDE IPC 完成之间的竞态窗口。

上轮审查的所有发现均已解决:--input-file 首次请求保证、postRenderConnectIde 默认值、fallbackModel 测试 mock、IDE 连接超时、错误堆栈保留、IDE 连接状态 UI。

进入代码审查 🔍

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator

Code Review

Re-reviewed the full diff against the latest commits (including fix(cli): preserve startup first-request guarantees, fix(cli): make post-render IDE connection opt-in, and perf(cli): surface deferred IDE connection status).

startup-prefetch.ts — Clean and well-guarded. WeakSet idempotency prevents double-runs (React StrictMode, test re-entries). runDeferredTask provides uniform lifecycle profiling (started/completed/failed) and error swallowing. withTimeout on deferred IDE connection is correct: 10s timeout with .catch() on the losing promise to surface the underlying error after timeout. The IDE connection status events (connecting/connected/failed) integrate with the new AppEvent.StartupIdeConnectionStatusChanged emitter.

initializer.tsconnectIdeForStartup extraction is minimal and correct. deferIdeConnection option defaults to false, preserving backward compatibility.

config.ts (core) — 3-line addition: deferTelemetryInitialization field, conditional guard on initializeTelemetry(), and a getter. No behavioral change for non-interactive or ACP paths.

gemini.tsxdeferIdeConnection computation now correctly includes hasRemoteInput (addresses wenshao's critical finding about --input-file first-request guarantees):

const deferIdeConnection =
  config.isInteractive() &&
  !config.getExperimentalZedIntegration() &&
  !input &&
  !hasRemoteInput;

startInteractiveUI.tsxpostRenderConnectIde defaults to false (opt-in), eliminating the double-connection footgun. The startPostRenderPrefetches call replaces the old inline checkForUpdates() cleanly.

Footer + AppContainer + events — The IDE connection status UI is a well-scoped addition. StartupIdeConnectionStatus discriminated union (idle/connecting/connected/failed) flows through UIStateContext, subscribed via useEffect in AppContainer, and rendered in Footer with appropriate messaging. The connected state hides the indicator (no noise after success). Good UX.

No correctness bugs, security holes, or regressions found. All prior review findings addressed.

Test Results

Unit tests pass (worktree):

✓ src/startup/startup-prefetch.test.ts (18 tests) 29ms
✓ src/core/initializer.test.ts (14 tests) 7ms
✓ src/gemini.test.tsx (37 tests) 16.98s
✓ src/config/config.test.ts - telemetry (12 tests) 14ms

Typecheck passes for all packages (core, cli, sdk, webui).

CI: ubuntu-latest tests pass (27m5s).

Real-Scenario Testing

Non-interactive mode (-p)

Before (installed build):

$ qwen -p 'what is 2+2?'
Warning: QWEN_HOME points to "..." but no settings.json was found there...
Warning: Tool "run_shell_command" requires user approval but cannot execute in non-interactive mode.
To enable automatic tool execution, use the -y flag (YOLO mode):
4

After (this PR via npm run dev):

$ npm run dev -- -p 'what is 2+2?'
> node scripts/dev.js -p what is 2+2?
Warning: QWEN_HOME points to "..." but no settings.json was found there...
DEV is set to true, but the React DevTools server is not running.
4

Both produce correct output (4). Non-interactive path unaffected.

Interactive mode (-i)

$ npm run dev -- -i
> node scripts/dev.js -i
Warning: QWEN_HOME points to "..." but no settings.json was found there...
DEV is set to true, but the React DevTools server is not running.
  ┌──────────────────────────────────────────────────────────────────────────┐
  │ >_ Qwen Code (v0.19.6)                                                   │
  │                                                                          │
  │ API Key | qwen3.7-max (/model to change)                                 │
  │ ~/actions-runner-13/_work/qwen-code/qwen-code                            │
  └──────────────────────────────────────────────────────────────────────────┘
  Tips: Type / to open the command popup; Tab autocompletes slash commands and
   saved prompts.

TUI rendered within 8 seconds. REPL header, model indicator, and tips all display correctly. Ctrl-C exits cleanly.

中文说明

代码审查

对照最新提交重新审查了完整 diff。

startup-prefetch.ts — 干净且有良好保护。WeakSet 幂等性防止重复运行。runDeferredTask 提供统一的生命周期分析和错误吞没。延迟 IDE 连接上的 withTimeout 正确:10 秒超时并在失败的 promise 上附加 .catch() 以在超时后显示底层错误。

initializer.tsconnectIdeForStartup 提取最小且正确。deferIdeConnection 默认值为 false,保持向后兼容。

config.ts(core) — 3 行新增,非交互式和 ACP 路径无行为变化。

gemini.tsxdeferIdeConnection 计算现在正确包含 hasRemoteInput(解决了 wenshao 关于 --input-file 首次请求保证的关键发现)。

startInteractiveUI.tsxpostRenderConnectIde 默认为 false(opt-in),消除了双重连接的隐患。

Footer + AppContainer + events — IDE 连接状态 UI 是范围清晰的新增。状态通过 UIStateContext 流转,在 Footer 中显示适当的消息。connected 状态隐藏指示器(成功后无噪音)。良好的用户体验。

未发现正确性 bug、安全漏洞或回归。所有先前审查发现均已解决。

测试结果

全部单元测试通过。TypeScript 类型检查通过。CI ubuntu-latest 测试通过。

真实场景测试

非交互模式(-p):前后均正确输出 "4",行为一致。

交互模式(-i):TUI 在 8 秒内渲染完成,REPL 正常显示。Ctrl-C 干净退出。

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator

Stepping back to look at the full picture after the author's latest fixes: this PR is now in good shape. The original concerns — the --input-file first-request race, the double-connection footgun, the missing fallbackModel in test mocks — are all resolved. The additional IDE connection status UI is a genuine improvement that closes the race-window UX gap for IDE extension users.

The implementation is straightforward: one new module (160 lines) with clear contracts, minimal changes to existing files, and comprehensive test coverage (81 tests across 4 suites, all passing). The core change is 3 lines. The deferIdeConnection gating logic correctly handles all five codepaths (plain TUI, prompt-interactive, -p, stream-json, ACP/Zed) with appropriate semantics for each.

My independent proposal would have been essentially the same: consolidate fire-and-forget operations into a controller module, add deferred options, guard with idempotency. The PR exceeds that with lifecycle profiler events, withTimeout on IDE connection, and the connection status indicator.

The design doc is thorough but serves as useful documentation for future maintainers working on startup performance — not over-engineering.

All CI green, typecheck clean, real-scenario tests confirm non-interactive parity and interactive TUI rendering. Approving. ✅

中文说明

在作者最新修复后回顾全局:这个 PR 现在状态良好。原始关注点——--input-file 首次请求竞态、双重连接隐患、测试 mock 中缺失的 fallbackModel——全部已解决。新增的 IDE 连接状态 UI 是真正的改进,填补了 IDE 扩展用户的竞态窗口 UX 缺口。

实现直截了当:一个 160 行的新模块,契约清晰,对现有文件变更最小,测试覆盖全面(4 个套件共 81 个测试全部通过)。核心变更仅 3 行。deferIdeConnection 门控逻辑正确处理了所有五条代码路径。

CI 全绿,类型检查干净,真实场景测试确认非交互式行为一致和交互式 TUI 正常渲染。批准。✅

Qwen Code · qwen3.7-max

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, looks ready to ship. ✅

debugLogger.warn(
`${name} failed: ${err instanceof Error ? err.message : String(err)}`,
);
});

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] runDeferredTask interpolates err.message into a string before passing it to debugLogger.warn, which drops the stack trace. The debugLogger's formatArgs already handles Error objects specially (extracting arg.stack), but it never sees the original error here.

Suggested change
});
debugLogger.warn(`${name} failed:`, err);

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed. runDeferredTask now passes the original error object to the logger:

debugLogger.warn(`${name} failed:`, err);

This preserves stack traces through formatArgs, and the startup prefetch tests assert the logger receives the original Error object.

preconnectApi(authType, { resolvedBaseUrl, proxy });
recordStartupEvent('startup_prefetch_started', { name: 'api_preconnect' });
} catch (error) {
debugLogger.debug(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] api_preconnect records startup_prefetch_started but never emits the corresponding startup_prefetch_completed or startup_prefetch_failed event. All post-render tasks go through runDeferredTask which records all three lifecycle events. This asymmetry means profiling consumers see api_preconnect as perpetually in-flight.

Consider either wrapping the call in runDeferredTask for consistency, or removing the manual recordStartupEvent here so only runDeferredTask-managed tasks appear in the startup_prefetch_* event stream.

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed. The manual startup_prefetch_started event for api_preconnect has been removed, so only tasks managed by runDeferredTask enter the balanced startup_prefetch_* lifecycle stream.

There is also a regression test asserting that api_preconnect does not emit an unbalanced lifecycle event.

});
}

runDeferredTask('background_housekeeping', async () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The original code in gemini.tsx guarded housekeeping with if (config.isInteractive()) and a comment explaining that non-interactive modes don't create the file-history dirs this cleans. That guard was dropped in the migration.

This is safe today since startPostRenderPrefetches is only called from startInteractiveUI, but the function's signature accepts any Config — a future non-interactive caller would silently start housekeeping. Consider restoring the guard for defense-in-depth, or adding a comment noting the implicit invariant.

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed. background_housekeeping is now guarded by config.isInteractive() inside startPostRenderPrefetches(), preserving the previous invariant even if a future non-interactive caller invokes this helper directly.

The startup prefetch tests cover the non-interactive case and assert housekeeping is not scheduled.

@wenshao wenshao requested a review from doudouOUC July 4, 2026 15:52

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Startup deferral is well-structured with good idempotency guards. A few findings target files outside this PR's diff (WebShellSidebar, autofix workflow, errorReporting, acpAgent) — flagging them here since inline comments aren't possible on those files:

  • WebShellSidebar (WebShellSidebar.tsx): handleArchive/handleUnarchive only reload the target list, not the source list — sessions appear in both active and archived sections simultaneously. confirmDeleteSession is missing the busySessionIdRef guard that archive/unarchive both have, allowing concurrent operations.
  • Autofix workflow (qwen-autofix.yml): The claim step removes autofix/approved before posting the claim comment. If the comment fails, the issue becomes invisible to all future automated scans (stuck state).
  • Test gaps: Archive/unarchive error paths, archived session deletion, actions.ts archive/unarchive, and loadSession/resumeSession concurrent isolation are all untested.
  • acpAgent: newSession still assigns this.settings before the first await, inconsistent with loadSession/resumeSession.

For files in the PR diff:

  • startup-prefetch.ts:96-99 — IDE connection failure in deferred mode is only debugLogger.warn. The symptom (IDE features don't work) and cause (deferred connection swallowed) are far apart for debugging.
  • config.ts:2014deferTelemetryInitialization defers for qwen -i "prompt" even though the first model request fires before post-render prefetches run. Consider excluding prompt-interactive from deferral, or add a comment explaining the accepted trade-off.

— qwen3.7-max via Qwen Code /review

Tighten the startup prefetch follow-up fixes from review while keeping
prompt-interactive telemetry on the fast interactive startup path.

- Preserve Error objects when deferred startup tasks fail
- Remove the unbalanced api_preconnect profiler lifecycle event
- Guard background housekeeping so it only runs for interactive configs
- Document and test prompt-interactive telemetry deferral semantics
Ensure sessions launched with an initial interactive prompt have
telemetry ready before the auto-submitted first request runs.

- Exclude prompt-interactive startup from telemetry deferral
- Pass a post-render telemetry option through interactive UI startup
- Skip duplicate post-render telemetry startup for initial prompts
- Update tests to cover the first-prompt telemetry guarantee

Note: Plain interactive TUI startup still defers telemetry post-render.

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No review findings. Downgraded from Approve to Comment: CI still running.

The deferred-startup design is well-structured — WeakSet idempotency guards are correct, all five startup paths (interactive, prompt-interactive, non-interactive, stream-json, ACP/Zed) maintain their correctness guarantees, and the new test suite covers the critical paths. A few low-confidence defensive-programming suggestions were identified but not flagged as inline comments (default-direction mismatch in startInteractiveUI options, duplicated telemetry defer condition across modules, and fragile ide_connect/telemetry_init ordering in startPostRenderPrefetches).

— qwen3.7-max via Qwen Code /review

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No review findings. Downgraded from Approve to Comment: CI still running.

— qwen3.7-max via Qwen Code /review

}

if (options.initializeTelemetry) {
runDeferredTask('telemetry_init', () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] telemetry_init is the only synchronous task among the deferred tasks. initializeTelemetry(config) runs as a single microtask inside Promise.resolve().then(task), blocking the event loop until OTel SDK setup completes (resource attributes, exporters, BatchSpanProcessor, PeriodicExportingMetricReader). Every other deferred task starts with await import(...) which yields to the event loop before doing real work.

This has two consequences:

  1. Performance: The sync microtask can delay React's post-mount useEffect state updates and pending I/O, partially undermining the "fast time-to-interactive" goal.
  2. Ordering dependency: Because telemetry_init is declared before ide_connect and runs synchronously, it completes before connectIdeForStartuplogIdeConnection executes. logIdeConnection guards on isTelemetrySdkInitialized() and silently drops the event if false. This ordering is accidental — if initializeTelemetry is ever made async or the task declarations are reordered, IDE connection telemetry events will be silently dropped with no test catching it.
Suggested change
runDeferredTask('telemetry_init', () => {
if (options.initializeTelemetry) {
runDeferredTask('telemetry_init', async () => {
const { initializeTelemetry: initTelemetry } = await import(
'@qwen-code/qwen-code-core'
);
initTelemetry(config);
});
}

Making this async with a dynamic import (consistent with the other tasks) yields to the event loop before running, and also removes the static initializeTelemetry import at the top of the file. To preserve the ordering guarantee with ide_connect, either sequence them explicitly or add a comment documenting the dependency.

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not addressed in this commit.

I kept telemetry_init synchronous for now because changing it to an async dynamic import also changes the implicit ordering with deferred ide_connect; logIdeConnection currently depends on telemetry already being initialized. Fixing this properly should sequence telemetry and IDE connection explicitly, or introduce a shared readiness contract, instead of only adding an async import and risking dropped IDE telemetry events.

This should be handled as a separate follow-up so the scheduling and telemetry-ordering behavior can be reviewed together.

}

if (options.connectIde && config.getIdeMode()) {
runDeferredTask('ide_connect', async () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The deferred IDE connection runs fire-and-forget via runDeferredTask with no timeout. If the IDE IPC endpoint is unresponsive (VS Code extension host restarted, stale socket file, JetBrains backend overloaded), the connect() promise hangs indefinitely inside runDeferredTask. The catch handler only fires on rejection, not on hang. The result: startup_prefetch_started with name: ide_connect is recorded but neither completed nor failed ever fires, and there is no diagnostic trail explaining why IDE context is missing.

This is a new failure mode introduced by the PR. The old code awaited connect() inside initializeApp() before first paint — a hang was immediately visible as "the TUI never appears." The new code makes the same hang invisible.

Consider wrapping the connection in a timeout (e.g., Promise.race with a 10s setTimeout that rejects), so the profiler captures the outcome and debugLogger.warn fires on timeout.

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 6cba2c578.

Deferred ide_connect is now wrapped in a 10s timeout. If the IDE IPC promise hangs, the timeout rejects through runDeferredTask, so the profiler records startup_prefetch_failed and the debug logger emits a clear timeout diagnostic instead of leaving the task permanently in-flight.

A unit test covers the hanging connection case.

Comment thread packages/cli/src/gemini.tsx Outdated
initializationResult!,
{
postRenderConnectIde: deferIdeConnection,
postRenderInitializeTelemetry: !input,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The telemetry deferral decision is split across two files with no type-level or comment-level link:

  • cli/config/config.ts decides whether to skip eager init: deferTelemetryInitialization: interactive && !isAcpMode && !question
  • This line decides whether to run post-render init: postRenderInitializeTelemetry: !input

The invariant deferTelemetryInitialization === postRenderInitializeTelemetry must hold for the interactive TUI path, or telemetry either never initializes (silent data loss) or initializes twice (masked today by the telemetryInitialized guard in sdk.ts, but that guard is an implementation detail that could change).

Today the conditions are equivalent (interactive && !isAcpMode && !question simplifies to !question on the interactive path, matching !input). But this relies on three unstated premises: ACP mode never reaches this branch, question and input are the same value, and no future condition is added to either side independently.

Consider adding a linking comment at both sites, or computing the deferral flag once and passing it through to both loadCliConfig and startInteractiveUI.

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 6cba2c578 with the Config-backed option.

Config now stores the telemetry deferral decision as telemetryInitializationDeferred, and startInteractiveUI() defaults post-render telemetry startup from config.isTelemetryInitializationDeferred() instead of recomputing a separate !input condition in gemini.tsx.

This keeps the eager-skip and post-render-init decisions tied to the same source of truth.

expect(config.getTelemetryEnabled()).toBe(true);
});

it('should initialize telemetry before prompt-interactive startup', async () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] This test verifies deferTelemetryInitialization: false for prompt-interactive, but there is no corresponding positive test verifying deferTelemetryInitialization: true for the ordinary interactive case (no -p, no -i "prompt", not ACP). The positive path is the primary motivation for the feature — deferring telemetry for ordinary TUI startup — yet it has no test. A regression that accidentally always sets false would go undetected.

Consider adding a test with process.argv = ['node', 'script.js', '-i'] (interactive, no question) that asserts deferTelemetryInitialization: true.

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 6cba2c578.

Added a positive loadCliConfig test for ordinary interactive startup that sets process.stdin.isTTY = true, uses no initial prompt, and asserts deferTelemetryInitialization: true is passed to Config.

const { initializeApp } = await import('./core/initializer.js');
const initializationResult = await initializeApp(config, settings);
let input = config.getQuestion();
const deferIdeConnection =

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] deferIdeConnection only treats a CLI question as an immediate first request, but interactive sessions with --input-file can submit as soon as RemoteInputWatcher.setSubmitFn() is installed. Since IDE connection is now fire-and-forget post-render, an IDE-enabled driver can send that first request before IDE context is connected, regressing the same first-request guarantee this PR preserves for prompt-interactive startup.

Suggested change
const deferIdeConnection =
const hasImmediateInteractiveSubmit = Boolean(
input || config.getInputFile?.(),
);
const deferIdeConnection =
config.isInteractive() &&
!config.getExperimentalZedIntegration() &&
!hasImmediateInteractiveSubmit;

— gpt-5 via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 6cba2c578.

deferIdeConnection now treats config.getInputFile?.() as an immediate interactive-submit source, so interactive sessions using --input-file keep the IDE connection awaited before the first remote prompt can run. The post-render prefetch receives connectIde: false in that case to avoid a duplicate connection attempt.

Added a gemini.test.tsx regression test for interactive + input file.

# Conflicts:
#	packages/cli/src/gemini.test.tsx
experimentalLsp: undefined,
channel: undefined,
chatRecording: undefined,
sessionId: undefined,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] [build] fallbackModel is missing from this mock CliArgs object. The same omission exists at line 1511. CliArgs.fallbackModel is required (no ?), so tsc --build reports TS2345 on both lines. Line 1267 already includes fallbackModel: undefined — these two were missed.

Suggested change
sessionId: undefined,
sessionId: undefined,
fallbackModel: undefined,

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 6cba2c578.

Both affected mock CliArgs objects now include fallbackModel: undefined, and packages/cli typecheck passes with tsc --noEmit.


await vi.dynamicImportSettled();

expect(mockCheckForUpdates).toHaveBeenCalledTimes(1);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] handleAutoUpdate is mocked (line 20) and wired into the handleAutoUpdate module mock (line 46), but no test asserts it was actually called. This test verifies checkForUpdates was called but skips the second half of the update pipeline — a regression that drops handleAutoUpdate from the .then() chain would pass all tests.

Suggested change
expect(mockCheckForUpdates).toHaveBeenCalledTimes(1);
expect(mockCheckForUpdates).toHaveBeenCalledTimes(1);
expect(mockHandleAutoUpdate).toHaveBeenCalledWith(
null,
expect.any(Object),
'/repo',
);

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not addressed in this commit.

I did not add this assertion because the current test intentionally uses a never-resolving checkForUpdates() promise to verify that startPostRenderPrefetches() starts work without awaiting completion. In that specific test, handleAutoUpdate should not run.

A separate test that resolves checkForUpdates() and asserts the handleAutoUpdate(info, settings, projectRoot) handoff would be useful, but it is a test-strengthening follow-up rather than part of the first-request correctness fixes in 6cba2c578.

heyang.why added 2 commits July 6, 2026 10:27
Keep deferred startup work from weakening first-request behavior in
interactive sessions that submit prompts automatically or remotely.

- Store telemetry deferral on Config and reuse that decision at render time
- Keep IDE startup awaited for prompt-interactive and input-file sessions
- Add a timeout for deferred IDE connection failures
- Cover ordinary interactive telemetry deferral and IDE startup edge cases
@water-in-stone

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed review. Status after 6cba2c578:

Fixed in this PR:

  • Deferred IDE failures now have better diagnostics: deferred ide_connect has a 10s timeout, so hangs record startup_prefetch_failed and emit a clear debug warning.
  • Prompt-interactive telemetry is no longer deferred: the telemetry deferral decision is stored on Config, and prompt-interactive startup keeps telemetry initialized before the first auto-submitted prompt.
  • --input-file interactive sessions also keep the IDE connection awaited before first remote submit.

Not fixed here:

  • The WebShellSidebar, autofix workflow, archive/unarchive test gaps, and acpAgent settings-order notes are outside this startup-prefetch PR's diff and should be tracked separately if we want to pursue them.

});
}
startPostRenderPrefetches(config, settings, {
connectIde: options.postRenderConnectIde ?? true,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The default options.postRenderConnectIde ?? true makes post-render IDE connection opt-out. Combined with initializeApp()'s default deferIdeConnection: false (eager), any future caller of startInteractiveUI that omits options will get a double IDE connection: once awaited in initializeApp, once fire-and-forget in startPostRenderPrefetches. IdeClient.connect() has no re-entry guard.

The sole production caller in gemini.tsx passes the option correctly, masking this footgun.

Suggested change
connectIde: options.postRenderConnectIde ?? true,
connectIde: options.postRenderConnectIde ?? false,

Changing the default to false makes the safe/already-connected state the default, matching initializeApp's eager-connect behavior. The gemini.tsx caller already passes postRenderConnectIde: deferIdeConnection explicitly, so no caller change is needed.

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in d05359056.

startInteractiveUI() now defaults postRenderConnectIde to false, so the already-connected/eager IDE path is the safe default and future callers that omit options will not accidentally connect twice. The production caller in gemini.tsx still passes postRenderConnectIde: deferIdeConnection explicitly, so ordinary TUI startup keeps the deferred IDE behavior when intended.

Updated startInteractiveUI tests to assert the safer default. Verified with:

cd packages/cli && npx vitest run src/gemini.test.tsx
cd packages/cli && npm run typecheck
git diff --check

heyang.why and others added 2 commits July 6, 2026 14:17
Default startInteractiveUI to the already-connected IDE path so future
callers do not accidentally connect twice when initializeApp used its
eager default.

- Change the post-render IDE connection default to false
- Update startInteractiveUI tests to assert the safer default
@water-in-stone

Copy link
Copy Markdown
Contributor Author

@wenshao @doudouOUC All the comments have been addressed. Please take another look. Thanks.

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No review findings. Downgraded from Approve to Comment: CI still running.

The startup deferral architecture is well-structured: WeakSet idempotency guards are correct, the runDeferredTask abstraction cleanly separates fire-and-forget work from the critical path, and all five startup flows (interactive, prompt-interactive, non-interactive, ACP/Zed, input-file) are properly handled with comprehensive test coverage (77/77 passing). Typecheck and ESLint clean.

— qwen3.7-max via Qwen Code /review

@water-in-stone water-in-stone requested a review from wenshao July 7, 2026 02:28

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

Approve ✅ — no Critical findings. All findings are Suggestions.

The fire-and-forget prefetch design is well-structured: WeakSet idempotency guards are correct, runDeferredTask cleanly separates concerns, the five startup paths maintain their correctness invariants, and all 336 tests pass across 5 suites.

Suggestions

  1. [startup-prefetch.ts:37] withTimeout masks the underlying promise's real error when the timeout fires first — after the timeout wins Promise.race, the original promise's rejection becomes unhandled. Consider attaching a .catch() to the losing promise.
  2. [gemini.tsx:756] IDE extension users see an interactive input area before the IDE connection completes — commands sent in that window lack IDE context. Consider a visual indicator.
  3. [config.ts:2055] Two accepted tradeoffs at this call site: (a) auth telemetry events emitted during the deferred init window are no-ops (acknowledged in the PR risk section), and (b) IDE deferral and telemetry deferral use different conditions (question matters for IDE but not telemetry) with no cross-reference explaining the asymmetry.
  4. [startup-prefetch.ts:108] handleAutoUpdate performs synchronous file-system I/O inside a fire-and-forget task — acceptable for small files, but worth documenting the assumption.
  5. [startup-prefetch.test.ts:147] No test asserts that startup_prefetch_completed is recorded by runDeferredTask. The code does it correctly, but a profiler lifecycle test would catch future regressions.
  6. [initializer.test.ts:199] connectIdeForStartup is tested with getIdeMode()=true but not directly with getIdeMode()=false — the early-return path lacks a dedicated unit test.

— qwen3.7-max via Qwen Code /review

});

return Promise.race([promise, timeout]).finally(() => {
if (timeoutId) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] withTimeout masks the real error when the timeout fires first. After Promise.race resolves with the timeout rejection, the original promise is still running — and if it eventually rejects, that rejection is silently consumed (no .catch() attached to the losing branch). The debug log will only show the timeout error, losing the actual root cause.

Consider attaching a .catch() to the losing promise to surface its error:

return Promise.race([promise, timeout]).finally(() => {
  clearTimeout(timeoutId);
});

Alternatively, log the underlying error in a separate .catch() on promise itself (e.g., promise.catch(err => debugLogger.debug(\${name} underlying error:`, err))`) so it doesn't mask the timeout but still surfaces the real cause.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 95c5f6c03.

I added a losing-promise catch in the deferred timeout helper so if ide_connect times out first and the underlying IDE connection later rejects, the original error is logged at debug level without changing the externally observed timeout failure.

const initializationResult = await initializeApp(config, settings);
let input = config.getQuestion();
const hasRemoteInput = Boolean(config.getInputFile?.());
const deferIdeConnection =

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The deferred IDE connection creates a race window for IDE extension users: the TUI renders with an interactive input area before the IDE IPC handshake completes. If a user types and submits a message during that window, the model request goes out without IDE context (workspace info, editor state).

For IDE extension sessions specifically, consider showing a brief "Connecting to IDE..." status indicator until ide_connect completes, or deferring input availability until the deferred IDE connection resolves. Ordinary CLI sessions are unaffected since connectIde is opt-in.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 95c5f6c03 with the status-only approach.

Ordinary interactive IDE TUI startup now emits deferred IDE connection lifecycle status and surfaces it in the footer: connecting shows IDE connecting... context may be unavailable, success clears the indicator, and failure/timeout leaves a warning. This keeps input available and preserves the post-render startup performance tradeoff while making the race window visible to users.

telemetry: telemetrySettings,
// Prompt-interactive (`qwen -i "prompt"`) auto-submits its first prompt,
// so telemetry must be ready before that request is sent.
deferTelemetryInitialization: interactive && !isAcpMode && !question,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Two accepted tradeoffs converge at this line, and it would help future readers to document both:

  1. Auth telemetry gap: auth-related events emitted between performInitialAuth() (which runs eagerly in initializeApp) and the deferred initializeTelemetry() call are no-ops. This is acknowledged in the PR risk section.
  2. Asymmetric deferral conditions: deferIdeConnection (in gemini.tsx) considers question (prompt-interactive doesn't defer IDE), but deferTelemetryInitialization here does not check question. The design rationale is sound — prompt-interactive needs IDE context for the auto-submitted prompt but doesn't need telemetry before the first request — but a cross-reference comment would prevent future maintainers from "fixing" the asymmetry.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 95c5f6c03.

I documented the accepted telemetry deferral tradeoffs at the deferTelemetryInitialization decision point: auth events before deferred telemetry init may be no-ops, and the telemetry condition is intentionally not identical to IDE deferral because prompt-interactive needs IDE context before auto-submit while telemetry can remain post-render unless an initial prompt is present.

import('../utils/handleAutoUpdate.js'),
]);
const info = await checkForUpdates();
handleAutoUpdate(info, settings, config.getProjectRoot());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] handleAutoUpdate(info, settings, config.getProjectRoot()) performs synchronous file-system I/O (writing update-notification files). Inside a fire-and-forget deferred task, this blocks the event loop briefly. For small files this is acceptable, but it's worth documenting the assumption that handleAutoUpdate is expected to be fast — or wrapping it in a Promise.resolve() boundary so it doesn't block other microtasks queued by the prefetch batch.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changed in this pass.

handleAutoUpdate still runs inside the post-render fire-and-forget task, and the synchronous work here is limited to small update-notification file I/O after first paint. Wrapping it in another microtask would not remove the synchronous I/O cost, and there is no current evidence that it causes visible startup contention. I would keep this as a follow-up only if profiling shows the update file write is material.


await vi.dynamicImportSettled();

expect(mockCheckForUpdates).toHaveBeenCalledTimes(1);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] No test in this suite asserts that recordStartupEvent('startup_prefetch_completed', { name }) is actually called by runDeferredTask. The production code does this correctly (line 56), but a profiler lifecycle test would catch regressions if someone restructures the .then() chain. Consider adding an assertion like:

expect(recordStartupEvent).toHaveBeenCalledWith(
  'startup_prefetch_completed',
  expect.objectContaining({ name: 'update_check' }),
);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 95c5f6c03.

I added a successful deferred update_check lifecycle test that asserts both startup_prefetch_started and startup_prefetch_completed are recorded, so the profiler completion contract is now covered alongside the failure path.

expect(mockLogIdeConnection).toHaveBeenCalled();
});

it('should not connect to IDE when not in IDE mode', async () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] connectIdeForStartup is tested at line 189 with getIdeMode()=true, but the early-return path (getIdeMode()=false → skip connection) is only tested indirectly through initializeApp at line 200. A dedicated unit test calling connectIdeForStartup(mockConfig) with getIdeMode()=false and asserting no IDE client calls would better document the contract and catch regressions if the guard condition changes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 95c5f6c03.

I added a direct connectIdeForStartup() early-return test for getIdeMode() === false, asserting that IdeClient.getInstance(), connect(), and logIdeConnection() are not called. This locks the shared helper contract directly instead of only covering it through initializeApp().

Make ordinary interactive IDE startup visible while preserving the
post-render prefetch path and first-paint performance tradeoff.

- Emit deferred IDE connection lifecycle events for connecting, success,
  and failure states
- Surface IDE startup status in the TUI footer without blocking input
- Log late underlying IDE failures after timeout for better diagnostics
- Document telemetry deferral tradeoffs and add startup lifecycle tests
@wenshao

wenshao commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

}

if (options.connectIde && config.getIdeMode()) {
runDeferredTask('ide_connect', async () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] When withTimeout fires at 10s, the underlying connectIdeForStartup promise is not cancelled — it continues running. If the IDE connection succeeds after the timeout (e.g., at second 11-12), IdeClient becomes connected and workspace data flows, but the UI permanently shows "IDE connection unavailable" because no connected status event is emitted after the timeout path. The failed state in StartupIdeConnectionStatus is terminal — there is no late-success recovery or disconnect on timeout.

This creates a state inconsistency where the footer claims the IDE is unavailable while it is actually connected and functional. Users may disable IDE integration or file bugs based on the misleading status.

Consider either:

  1. Disconnecting the IDE client on timeout so the internal state stays consistent with the UI, or
  2. Keeping a reference to the underlying promise and emitting { state: 'connected' } if it eventually succeeds after the timeout.

— qwen3.7-max via Qwen Code /review

}
});

it('fails deferred IDE connection when the startup connect hangs', async () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The test suite covers the timeout scenario (hanging promise + 10s advance) and the success path, but there is no test for a quick IDE connection failure before the timeout fires. This is a distinct code branch: the catch block at the end of the ide_connect task extracts the error message via err instanceof Error ? err.message : String(err) and emits { state: 'failed', message }. A quick-rejection test (e.g., mockConnectIdeForStartup.mockRejectedValue(new Error('connection refused'))) would verify this path and the error message extraction logic.

it('fails deferred IDE connection when the startup connect rejects quickly', async () => {
  const config = makeConfig();
  mockConnectIdeForStartup.mockRejectedValue(new Error('connection refused'));
  const { statuses, stop } = captureIdeConnectionStatuses();

  try {
    startPostRenderPrefetches(config, makeSettings(), { connectIde: true });
    await vi.dynamicImportSettled();

    expect(statuses).toEqual([
      { state: 'connecting' },
      { state: 'failed', message: 'connection refused' },
    ]);
    expect(mockRecordStartupEvent).toHaveBeenCalledWith(
      'startup_prefetch_failed',
      { name: 'ide_connect' },
    );
  } finally {
    stop();
  }
});

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, looks ready to ship. All prior review findings (input-file race, double-connection footgun, fallbackModel mock, IDE timeout) are resolved. ✅

@wenshao

wenshao commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

✅ Independent end-to-end verification — LGTM

Built the real CLI from this PR's head (95c5f6c, v0.19.6) and exercised every startup mode on Linux against a live provider. Ordering was observed through this PR's own startup profiler (QWEN_CODE_PROFILE_STARTUP=1), which timestamps each deferred task relative to the first_paint checkpoint — so the contract is proven from the code's own instrumentation, not inferred from reading the diff.

Result: all acceptance criteria in the design doc hold — deferral is correct per-mode, non-interactive/prompt-interactive/ACP semantics are preserved, and a failing deferred task cannot break the REPL. Recommend merge.

Environment

  • Worktree at PR head 95c5f6c033; packages/core + packages/cli built from source, real qwen binary (v0.19.6).
  • TUI driven over tmux; provider deepseek-chat (OpenAI-compatible); telemetry enabled with target: local so the deferred init does real work.

1 · Ordinary interactive TUI — non-critical work moved past first paint

qwen (TTY, no prompt). Profiler report:

task started vs first_paint
first_paint checkpoint 1351 ms
update_check 1373.6 ms +22 ms
telemetry_init 1373.7 ms +22 ms ✅ (completes +30 ms)
background_housekeeping 1373.7 ms +22 ms

All three launch after first paint. ✅

2 · Ordinary TUI + IDE mode — IDE connect also deferred, status surfaced live

qwen with ide.enabled: true. IDE connect now joins the post-paint batch:

task started vs first_paint
first_paint 1359 ms
ide_connect 1382.1 ms +23 ms
update_check · telemetry_init · background_housekeeping 1382 ms +23 ms ✅

To exercise the new footer status + the 10 s timeout, I pointed the IDE lock at a black-hole port (accepts TCP, never replies). The REPL renders in ~1.4 s and stays fully interactive while the connect hangs in the background:

(a) connecting — advisory + spinner, input box already live:
IDE connecting

(b) times out after 10 s and fails gracefully — REPL never blocked:
IDE connection unavailable

On main this same connect() is await-ed inside initializeApp() before startInteractiveUI(), so a slow/hung IDE would delay first paint by up to the connection time (here, the whole 10 s). This PR decouples them — that's the concrete win, and the new withTimeout + status events make the failure observable instead of silent.

3 · Prompt-interactive (qwen -i "…") — IDE awaited pre-render, telemetry NOT deferred

qwen -i "say only the word READY" (auto-submits; real model reply below), run with ide.enabled: true so the IDE result is meaningful:

task in post-paint batch?
update_check yes (+22 ms)
background_housekeeping yes (+22 ms)
ide_connect absent ✅ — IDE await-ed before the auto-submit (deferIdeConnection=false), no post-render reconnect
telemetry_init absent ✅ — initialised in the Config ctor pre-render (deferTelemetryInitialization=false because a prompt is present)

prompt-interactive

4 · Non-interactive (qwen -p "…") — path unchanged

interactiveMode=false, zero startup_prefetch events (no post-paint phase exists here); the report's config_initialize_* and mcp_all_servers_settled checkpoints confirm MCP readiness is still awaited on the critical path before the first request. ✅

5 · Unit tests — 362 pass, 0 fail

suite tests
cli/src/startup/startup-prefetch.test.ts 18 ✅
cli/src/core/initializer.test.ts 14 ✅
cli/src/ui/components/Footer.test.tsx 22 ✅
cli/src/config/config.test.ts (incl. 2 new defer-telemetry loadCliConfig cases) 259 ✅
cli/src/gemini.test.tsx (defer-IDE routing per mode) 37 ✅
core/src/config/config.test.ts -t telemetry (defer ctor) 12 ✅

Minor (non-blocking, cosmetic)

The failed-connect footer reads IDE connection unavailable: ide_connect timed out after 10000ms — it surfaces the internal task name ide_connect verbatim (from withTimeout(..., 'ide_connect', ...) in startup-prefetch.ts). A user-facing phrasing (e.g. "IDE connection timed out") would read a little cleaner. Purely cosmetic.


🇨🇳 中文验证报告(点击展开)

✅ 独立端到端验证 — 建议合并

从本 PR 的 HEAD(95c5f6cv0.19.6构建了真实的 CLI,在 Linux 上针对真实模型 provider 跑通了所有启动模式。任务的先后顺序通过 本 PR 自带的启动性能分析器QWEN_CODE_PROFILE_STARTUP=1)观测——它会记录每个延迟任务相对 first_paint(首帧)检查点的时间戳,因此这是用代码自身的埋点证明契约,而非从 diff 推断。

结论:设计文档中的所有验收标准均成立——各模式的延迟行为正确,非交互 / prompt-interactive / ACP 语义均保持不变,且延迟任务失败不会影响 REPL。建议合并。

环境

  • worktree 位于 PR HEAD 95c5f6c033packages/core + packages/cli 从源码构建,真实 qwen 二进制(v0.19.6)。
  • 通过 tmux 驱动真实 TUI;provider 为 deepseek-chat(OpenAI 兼容);开启 telemetry 且 target: local,使延迟的初始化真正执行。

1 · 普通交互式 TUI —— 非关键工作被移到首帧之后

qwen(有 TTY、无 prompt)。分析器报告:

任务 启动时刻 相对 first_paint
first_paint 检查点 1351 ms
update_check 1373.6 ms +22 ms
telemetry_init 1373.7 ms +22 ms ✅(+30 ms 完成)
background_housekeeping 1373.7 ms +22 ms

三者全部在首帧之后启动。✅

2 · 普通 TUI + IDE 模式 —— IDE 连接同样被延迟,并实时展示状态

qwenide.enabled: true。IDE 连接现在也进入首帧后批次:

任务 启动时刻 相对 first_paint
first_paint 1359 ms
ide_connect 1382.1 ms +23 ms
update_check · telemetry_init · background_housekeeping 1382 ms +23 ms ✅

为了触发新的 footer 状态 + 10 秒超时,我把 IDE lock 指向一个黑洞端口(接受 TCP 但永不响应)。REPL 在约 1.4 s 完成渲染,且在后台连接挂起期间始终可交互

(a) 连接中 —— 提示语 + spinner,输入框已可用:
IDE connecting

(b) 10 秒后超时并优雅失败 —— REPL 从未被阻塞:
IDE connection unavailable

main 上,同一个 connect() 是在 startInteractiveUI() 之前、于 initializeApp() 内被 await 的,因此一个慢速 / 挂起的 IDE 会把首帧延迟最多整整这段连接时间(此处即整整 10 s)。本 PR 解耦了两者——这就是实际收益;新增的 withTimeout + 状态事件也让失败变得可见,而不再是静默。

3 · Prompt-interactive(qwen -i "…")—— IDE 在渲染前 await,telemetry 延迟

qwen -i "say only the word READY"(自动提交;下方为真实模型回复),本次开启了 ide.enabled: true 使 IDE 结论有意义:

任务 是否在首帧后批次?
update_check 是(+22 ms)
background_housekeeping 是(+22 ms)
ide_connect 不在 ✅ —— IDE 在自动提交前已被 awaitdeferIdeConnection=false),渲染后不再重复连接
telemetry_init 不在 ✅ —— 因存在 prompt(deferTelemetryInitialization=false),已在 Config 构造函数中于渲染前完成初始化

prompt-interactive

4 · 非交互(qwen -p "…")—— 路径未变

interactiveMode=false startup_prefetch 事件(该路径本就没有首帧后阶段);报告中的 config_initialize_*mcp_all_servers_settled 检查点确认首次请求前仍在关键路径上等待 MCP 就绪。✅

5 · 单元测试 —— 362 通过,0 失败

套件 用例数
cli/src/startup/startup-prefetch.test.ts 18 ✅
cli/src/core/initializer.test.ts 14 ✅
cli/src/ui/components/Footer.test.tsx 22 ✅
cli/src/config/config.test.ts(含 2 个新增的 defer-telemetry loadCliConfig 用例) 259 ✅
cli/src/gemini.test.tsx(各模式的 defer-IDE 路由) 37 ✅
core/src/config/config.test.ts -t telemetry(构造函数延迟) 12 ✅

次要(不阻塞,属观感问题)

失败时 footer 显示 IDE connection unavailable: ide_connect timed out after 10000ms——它把内部任务名 ide_connect 原样透出给用户(来自 startup-prefetch.ts 中的 withTimeout(..., 'ide_connect', ...))。改成更面向用户的措辞(如 "IDE connection timed out")会更自然。纯观感。


Verified against PR head 95c5f6c. Method: real built CLI + QWEN_CODE_PROFILE_STARTUP event timestamps vs first_paint, across ordinary / IDE-mode / prompt-interactive / non-interactive; new footer status driven with a black-hole IDE port to exercise the 10 s timeout path.

@wenshao wenshao added this pull request to the merge queue Jul 7, 2026
Merged via the queue into QwenLM:main with commit 3d1122d Jul 7, 2026
133 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants